 /*******************************************************************************
  * Copyright (c) 2004, 2007 IBM Corporation and others.
  * All rights reserved. This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License v1.0
  * which accompanies this distribution, and is available at
  * http://www.eclipse.org/legal/epl-v10.html
  *
  * Contributors:
  * IBM Corporation - initial API and implementation
  *******************************************************************************/

 package org.eclipse.ui.internal.keys;

 import java.util.ArrayList ;
 import java.util.Collection ;
 import java.util.Comparator ;
 import java.util.Iterator ;
 import java.util.List ;
 import java.util.Map ;
 import java.util.ResourceBundle ;
 import java.util.SortedMap ;
 import java.util.TreeMap ;

 import org.eclipse.core.commands.Command;
 import org.eclipse.core.commands.ParameterizedCommand;
 import org.eclipse.core.commands.common.CommandException;
 import org.eclipse.core.commands.common.NotDefinedException;
 import org.eclipse.jface.bindings.Binding;
 import org.eclipse.jface.bindings.TriggerSequence;
 import org.eclipse.jface.bindings.keys.KeySequence;
 import org.eclipse.jface.bindings.keys.KeyStroke;
 import org.eclipse.jface.dialogs.Dialog;
 import org.eclipse.jface.dialogs.PopupDialog;
 import org.eclipse.jface.preference.PreferenceDialog;
 import org.eclipse.jface.window.Window;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.graphics.Rectangle;
 import org.eclipse.swt.layout.GridData;
 import org.eclipse.swt.layout.GridLayout;
 import org.eclipse.swt.widgets.Composite;
 import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Event;
 import org.eclipse.swt.widgets.Label;
 import org.eclipse.swt.widgets.Listener;
 import org.eclipse.swt.widgets.Shell;
 import org.eclipse.swt.widgets.Table;
 import org.eclipse.swt.widgets.TableColumn;
 import org.eclipse.swt.widgets.TableItem;
 import org.eclipse.ui.IWorkbench;
 import org.eclipse.ui.activities.IActivityManager;
 import org.eclipse.ui.commands.ICommandService;
 import org.eclipse.ui.contexts.IContextService;
 import org.eclipse.ui.dialogs.PreferencesUtil;
 import org.eclipse.ui.internal.util.Util;
 import org.eclipse.ui.keys.IBindingService;

 import com.ibm.icu.text.MessageFormat;

 /**
  * <p>
  * A dialog displaying a list of key bindings. The dialog will execute a command
  * if it is selected.
  * </p>
  * <p>
  * The methods on this class are not thread-safe and must be run from the UI
  * thread.
  * </p>
  *
  * @since 3.1
  */
 final class KeyAssistDialog extends PopupDialog {

     /**
      * The data key for the binding stored on an SWT widget. The key is a
      * fully-qualified name, but in reverse order. This is so that the equals
      * method will detect misses faster.
      */
     private static final String BINDING_KEY = "Binding.bindings.jface.eclipse.org"; //$NON-NLS-1$

     /**
      * The value of <code>previousWidth</code> to set if there is no
      * remembered width.
      */
     private static final int NO_REMEMBERED_WIDTH = -1;

     /**
      * The translation bundle in which to look up internationalized text.
      */
     private static final ResourceBundle RESOURCE_BUNDLE = ResourceBundle
             .getBundle(KeyAssistDialog.class.getName());

     /**
      * The activity manager for the associated workbench.
      */
     private final IActivityManager activityManager;

     /**
      * The binding service for the associated workbench.
      */
     private final IBindingService bindingService;

     /**
      * The binding that was selected when the key assist dialog last closed.
      * This is only remembered until <code>clearRememberedState()</code> is
      * called.
      */
     private Binding binding = null;

     /**
      * The ordered list of command identifiers corresponding to the table.
      */
     private final List bindings = new ArrayList ();

     /**
      * The command service for the associated workbench.
      */
     private final ICommandService commandService;

     /**
      * The table containing of the possible completions. This value is
      * <code>null</code> until the dialog is created.
      */
     private Table completionsTable = null;

     /**
      * Whether this dialog is currently holding some remembered state.
      */
     private boolean hasRememberedState = false;

     /**
      * The key binding state for the associated workbench.
      */
     private final KeyBindingState keyBindingState;

     /**
      * The width of the shell when it was previously open. This is only
      * remembered until <code>clearRememberedState()</code> is called.
      */
     private int previousWidth = NO_REMEMBERED_WIDTH;

     /**
      * The key binding listener for the associated workbench.
      */
     private final WorkbenchKeyboard workbenchKeyboard;

     /**
      * A sorted map of conflicts to be used when the dialog pops up.
      *
      * @since 3.3
      */
     private SortedMap conflictMatches;

     /**
      * Constructs a new instance of <code>KeyAssistDialog</code>. When the
      * dialog is first constructed, it contains no widgets. The dialog is first
      * created with no parent. If a parent is required, call
      * <code>setParentShell()</code>. Also, between uses, it might be
      * necessary to call <code>setParentShell()</code> as well.
      *
      * @param workbench
      * The workbench in which this dialog is created; must not be
      * <code>null</code>.
      * @param associatedKeyboard
      * The key binding listener for the workbench; must not be
      * <code>null</code>.
      * @param associatedState
      * The key binding state associated with the workbench; must not
      * be <code>null</code>.
      */
     KeyAssistDialog(final IWorkbench workbench,
             final WorkbenchKeyboard associatedKeyboard,
             final KeyBindingState associatedState) {
         super((Shell) null, PopupDialog.INFOPOPUP_SHELLSTYLE, true, false,
                 false, false, null, null);

         this.activityManager = workbench.getActivitySupport()
                 .getActivityManager();
         this.bindingService = (IBindingService) workbench
                 .getService(IBindingService.class);
         this.commandService = (ICommandService) workbench
                 .getService(ICommandService.class);
         this.keyBindingState = associatedState;
         this.workbenchKeyboard = associatedKeyboard;

         this.setInfoText(getKeySequenceString());
     }

     /**
      * Clears out the remembered state of the key assist dialog. This includes
      * its width, as well as the selected binding.
      */
     final void clearRememberedState() {
         previousWidth = NO_REMEMBERED_WIDTH;
         binding = null;
         hasRememberedState = false;
     }

     /**
      * Closes this shell, but first remembers some state of the dialog. This way
      * it will have a response if asked to open the dialog again or if asked to
      * open the keys preference page. This does not remember the internal state.
      *
      * @return Whether the shell was already closed.
      */
     public final boolean close() {
         return close(false);
     }

     /**
      * Closes this shell, but first remembers some state of the dialog. This way
      * it will have a response if asked to open the dialog again or if asked to
      * open the keys preference page.
      *
      * @param rememberState
      * Whether the internal state should be remembered.
      * @return Whether the shell was already closed.
      */
     public final boolean close(final boolean rememberState) {
         return close(rememberState, true);
     }

     /**
      * Closes this shell, but first remembers some state of the dialog. This way
      * it will have a response if asked to open the dialog again or if asked to
      * open the keys preference page.
      *
      * @param rememberState
      * Whether the internal state should be remembered.
      * @param resetState
      * Whether the state should be reset.
      * @return Whether the shell was already closed.
      */
     private final boolean close(final boolean rememberState,
             final boolean resetState) {
         final Shell shell = getShell();
         if (rememberState) {
             // Remember the previous width.
 final int widthToRemember;
             if ((shell != null) && (!shell.isDisposed())) {
                 widthToRemember = getShell().getSize().x;
             } else {
                 widthToRemember = NO_REMEMBERED_WIDTH;
             }

             // Remember the selected command name and key sequence.
 final Binding bindingToRemember;
             if ((completionsTable != null) && (!completionsTable.isDisposed())) {
                 final int selectedIndex = completionsTable.getSelectionIndex();
                 if (selectedIndex != -1) {
                     final TableItem selectedItem = completionsTable
                             .getItem(selectedIndex);
                     bindingToRemember = (Binding) selectedItem
                             .getData(BINDING_KEY);
                 } else {
                     bindingToRemember = null;
                 }
             } else {
                 bindingToRemember = null;
             }

             rememberState(widthToRemember, bindingToRemember);
             completionsTable = null;
         }

         if (resetState) {
             keyBindingState.reset();
         }
         return super.close();
     }

     /**
      * Sets the position for the dialog based on the position of the workbench
      * window. The dialog is flush with the bottom right corner of the workbench
      * window. However, the dialog will not appear outside of the display's
      * client area.
      *
      * @param size
      * The final size of the dialog; must not be <code>null</code>.
      */
     private final void configureLocation(final Point size) {
         final Shell shell = getShell();

         final Shell workbenchWindowShell = keyBindingState
                 .getAssociatedWindow().getShell();
         final int xCoord;
         final int yCoord;
         if (workbenchWindowShell != null) {
             /*
              * Position the shell at the bottom right corner of the workbench
              * window
              */
             final Rectangle workbenchWindowBounds = workbenchWindowShell
                     .getBounds();
             xCoord = workbenchWindowBounds.x + workbenchWindowBounds.width
                     - size.x - 10;
             yCoord = workbenchWindowBounds.y + workbenchWindowBounds.height
                     - size.y - 10;

         } else {
             xCoord = 0;
             yCoord = 0;

         }
         final Rectangle bounds = new Rectangle(xCoord, yCoord, size.x, size.y);
         shell.setBounds(getConstrainedShellBounds(bounds));
     }

     /**
      * Sets the size for the dialog based on its previous size. The width of the
      * dialog is its previous width, if it exists. Otherwise, it is simply the
      * packed width of the dialog. The maximum width is 40% of the workbench
      * window's width. The dialog's height is the packed height of the dialog to
      * a maximum of half the height of the workbench window.
      *
      * @return The size of the dialog
      */
     private final Point configureSize() {
         final Shell shell = getShell();

         // Get the packed size of the shell.
 shell.pack();
         final Point size = shell.getSize();

         // Use the previous width if appropriate.
 if ((previousWidth != NO_REMEMBERED_WIDTH) && (previousWidth > size.x)) {
             size.x = previousWidth;
         }

         // Enforce maximum sizing.
 final Shell workbenchWindowShell = keyBindingState
                 .getAssociatedWindow().getShell();
         if (workbenchWindowShell != null) {
             final Point workbenchWindowSize = workbenchWindowShell.getSize();
             final int maxWidth = workbenchWindowSize.x * 2 / 5;
             final int maxHeight = workbenchWindowSize.y / 2;
             if (size.x > maxWidth) {
                 size.x = maxWidth;
             }
             if (size.y > maxHeight) {
                 size.y = maxHeight;
             }
         }

         // Set the size for the shell.
 shell.setSize(size);
         return size;
     }

     /**
      * Returns a string representing the key sequence used to open this dialog.
      *
      * @return the string describing the key sequence, or <code>null</code> if
      * it cannot be determined.
      */
     private String getKeySequenceString() {
         final Command command = commandService
                 .getCommand("org.eclipse.ui.window.showKeyAssist"); //$NON-NLS-1$
 final TriggerSequence[] keyBindings = bindingService
                 .getActiveBindingsFor(new ParameterizedCommand(command, null));
         final int keyBindingsCount = keyBindings.length;
         final KeySequence currentState = keyBindingState.getCurrentSequence();
         final int prefixSize = currentState.getKeyStrokes().length;

         // Try to find the first possible matching key binding.
 KeySequence keySequence = null;
         for (int i = 0; i < keyBindingsCount; i++) {
             keySequence = (KeySequence) keyBindings[i];

             // Now just double-check to make sure the key is still possible.
 if (prefixSize > 0) {
                 if (keySequence.startsWith(currentState, false)) {
                     /*
                      * Okay, so we have a partial match. Replace the key binding
                      * with the required suffix completion.
                      */
                     final KeyStroke[] oldKeyStrokes = keySequence
                             .getKeyStrokes();
                     final int newSize = oldKeyStrokes.length - prefixSize;
                     final KeyStroke[] newKeyStrokes = new KeyStroke[newSize];
                     System.arraycopy(oldKeyStrokes, prefixSize, newKeyStrokes,
                             0, newSize);
                     keySequence = KeySequence.getInstance(newKeyStrokes);
                     break;
                 }

                 /*
                  * The prefix doesn't match, so null out the key binding and try
                  * again.
                  */
                 keySequence = null;
                 continue;

             }

             // There is no prefix, so just grab the first.
 break;
         }
         if (keySequence == null) {
             return null; // couldn't find a suitable key binding
 }

         return MessageFormat.format(Util.translateString(RESOURCE_BUNDLE,
                 "openPreferencePage"), //$NON-NLS-1$
 new Object [] { keySequence.format() });
     }

     /**
      * Creates the content area for the key assistant. This creates a table and
      * places it inside the composite. The composite will contain a list of all
      * the key bindings.
      *
      * @param parent
      * The parent composite to contain the dialog area; must not be
      * <code>null</code>.
      */
     protected final Control createDialogArea(final Composite parent) {
         // First, register the shell type with the context support
 registerShellType();

         // Create a composite for the dialog area.
 final Composite composite = new Composite(parent, SWT.NONE);
         final GridLayout compositeLayout = new GridLayout();
         compositeLayout.marginHeight = 0;
         compositeLayout.marginWidth = 0;
         composite.setLayout(compositeLayout);
         composite.setLayoutData(new GridData(GridData.FILL_BOTH));
         composite.setBackground(parent.getBackground());

         // Layout the partial matches.
 final SortedMap partialMatches;
         if (conflictMatches != null) {
             partialMatches = conflictMatches;
             conflictMatches = null;
         } else {
             partialMatches = getPartialMatches();
         }

         if (partialMatches.isEmpty()) {
             createEmptyDialogArea(composite);
         } else {
             createTableDialogArea(composite, partialMatches);
         }
         return composite;
     }

     /**
      * Creates an empty dialog area with a simple message saying there were no
      * matches. This is used if no partial matches could be found. This should
      * not really ever happen, but might be possible if the commands are
      * changing while waiting for this dialog to open.
      *
      * @param parent
      * The parent composite for the dialog area; must not be
      * <code>null</code>.
      */
     private final void createEmptyDialogArea(final Composite parent) {
         final Label noMatchesLabel = new Label(parent, SWT.NULL);
         noMatchesLabel.setText(Util.translateString(RESOURCE_BUNDLE,
                 "NoMatches.Message")); //$NON-NLS-1$
 noMatchesLabel.setLayoutData(new GridData(GridData.FILL_BOTH));
         noMatchesLabel.setBackground(parent.getBackground());
     }

     /**
      * Creates a dialog area with a table of the partial matches for the current
      * key binding state. The table will be either the minimum width, or
      * <code>previousWidth</code> if it is not
      * <code>NO_REMEMBERED_WIDTH</code>.
      *
      * @param parent
      * The parent composite for the dialog area; must not be
      * <code>null</code>.
      * @param partialMatches
      * The lexicographically sorted map of partial matches for the
      * current state; must not be <code>null</code> or empty.
      */
     private final void createTableDialogArea(final Composite parent,
             final SortedMap partialMatches) {
         // Layout the table.
 completionsTable = new Table(parent, SWT.FULL_SELECTION | SWT.SINGLE);
         final GridData gridData = new GridData(GridData.FILL_BOTH);
         completionsTable.setLayoutData(gridData);
         completionsTable.setBackground(parent.getBackground());
         completionsTable.setLinesVisible(true);

         // Initialize the columns and rows.
 bindings.clear();
         final TableColumn columnCommandName = new TableColumn(completionsTable,
                 SWT.LEFT, 0);
         final TableColumn columnKeySequence = new TableColumn(completionsTable,
                 SWT.LEFT, 1);
         final Iterator itemsItr = partialMatches.entrySet().iterator();
         while (itemsItr.hasNext()) {
             final Map.Entry entry = (Map.Entry ) itemsItr.next();
             final TriggerSequence sequence = (TriggerSequence) entry.getValue();
             final Binding binding = (Binding) entry.getKey();
             final ParameterizedCommand command = binding
                     .getParameterizedCommand();
             try {
                 final String [] text = { command.getName(), sequence.format() };
                 final TableItem item = new TableItem(completionsTable, SWT.NULL);
                 item.setText(text);
                 item.setData(BINDING_KEY, binding);
                 bindings.add(binding);
             } catch (NotDefinedException e) {
                 // Not much to do, but this shouldn't really happen.
 }
         }

         Dialog.applyDialogFont(parent);
         columnKeySequence.pack();
         if (previousWidth != NO_REMEMBERED_WIDTH) {
             columnKeySequence.setWidth(previousWidth);
         }
         columnCommandName.pack();

         /*
          * If you double-click on the table, it should execute the selected
          * command.
          */
         completionsTable.addListener(SWT.DefaultSelection, new Listener() {
             public final void handleEvent(final Event event) {
                 executeKeyBinding(event);
             }
         });
     }

     /**
      * Edits the remembered selection in the preference dialog.
      */
     private final void editKeyBinding() {
         // Create a preference dialog on the keys preference page.
 final String keysPageId = "org.eclipse.ui.preferencePages.Keys"; //$NON-NLS-1$
 final PreferenceDialog dialog = PreferencesUtil
                 .createPreferenceDialogOn(getShell(), keysPageId, null, binding);

         /*
          * Forget the remembered state (so we don't get stuck editing
          * preferences).
          */
         clearRememberedState();

         // Open the dialog (blocking).
 dialog.open();
     }

     /**
      * Handles the default selection event on the table of possible completions.
      * This attempts to execute the given command.
      */
     private final void executeKeyBinding(final Event trigger) {
         // Try to execute the corresponding command.
 final int selectionIndex = completionsTable.getSelectionIndex();
         if (selectionIndex >= 0) {
             final Binding binding = (Binding) bindings.get(selectionIndex);
             try {
                 workbenchKeyboard.executeCommand(binding, trigger);
             } catch (final CommandException e) {
                 workbenchKeyboard.logException(e, binding
                         .getParameterizedCommand());
             }
         }
     }

     /**
      * Gets the list of key bindings that are partial matches to the current key
      * binding state.
      *
      * @return A sorted map of key sequences (KeySequence) to command identifier
      * (String) representing the list of enabled commands that could
      * possibly complete the current key sequence.
      */
     private final SortedMap getPartialMatches() {
         // Put all partial matches into the matches into the map.
 final Map partialMatches = bindingService
                 .getPartialMatches(keyBindingState.getCurrentSequence());

         // Create a sorted map that sorts based on lexicographical order.
 final SortedMap sortedMatches = new TreeMap (new Comparator () {
             public final int compare(final Object a, final Object b) {
                 final Binding bindingA = (Binding) a;
                 final Binding bindingB = (Binding) b;
                 final ParameterizedCommand commandA = bindingA
                         .getParameterizedCommand();
                 final ParameterizedCommand commandB = bindingB
                         .getParameterizedCommand();
                 try {
                     return commandA.getName().compareTo(commandB.getName());
                 } catch (final NotDefinedException e) {
                     // should not happen
 return 0;
                 }
             }
         });

         /*
          * Remove those partial matches for which either the command is not
          * identified or the activity manager believes the command is not
          * enabled.
          */
         final Iterator partialMatchItr = partialMatches.entrySet().iterator();
         while (partialMatchItr.hasNext()) {
             final Map.Entry entry = (Map.Entry ) partialMatchItr.next();
             final Binding binding = (Binding) entry.getValue();
             final Command command = binding.getParameterizedCommand()
                     .getCommand();
             if (command.isDefined()
                     && activityManager.getIdentifier(command.getId())
                             .isEnabled()) {
                 sortedMatches.put(binding, entry.getKey());
             }
         }

         return sortedMatches;

     }

     /**
      * Returns whether the dialog is currently holding some remembered state.
      *
      * @return <code>true</code> if the dialog has remembered state;
      * <code>false</code> otherwise.
      */
     private final boolean hasRememberedState() {
         return hasRememberedState;
     }

     /**
      * Opens this dialog. This method can be called multiple times on the same
      * dialog. This only opens the dialog if there is no remembered state; if
      * there is remembered state, then it tries to open the preference page
      * instead.
      *
      * @return The return code from this dialog.
      */
     public final int open() {
         // If there is remember state, open the preference page.
 if (hasRememberedState()) {
             editKeyBinding();
             clearRememberedState();
             return Window.OK;
         }

         // If the dialog is already open, dispose the shell and recreate it.
 final Shell shell = getShell();
         if (shell != null) {
             close(false, false);
         }
         create();

         // Configure the size and location.
 final Point size = configureSize();
         configureLocation(size);

         // Call the super method.
 return super.open();
     }

     /**
      * Opens this dialog with the list of bindings for the user to select from.
      *
      * @return The return code from this dialog.
      * @since 3.3
      */
     public final int open(Collection bindings) {
         conflictMatches = new TreeMap (new Comparator () {
             public final int compare(final Object a, final Object b) {
                 final Binding bindingA = (Binding) a;
                 final Binding bindingB = (Binding) b;
                 final ParameterizedCommand commandA = bindingA
                         .getParameterizedCommand();
                 final ParameterizedCommand commandB = bindingB
                         .getParameterizedCommand();
                 try {
                     return commandA.getName().compareTo(commandB.getName());
                 } catch (final NotDefinedException e) {
                     // should not happen
 return 0;
                 }
             }
         });
         Iterator i = bindings.iterator();
         while (i.hasNext()) {
             Binding b = (Binding) i.next();
             conflictMatches.put(b, b.getTriggerSequence());
         }

         // If the dialog is already open, dispose the shell and recreate it.
 final Shell shell = getShell();
         if (shell != null) {
             close(false, false);
         }
         create();

         // Configure the size and location.
 final Point size = configureSize();
         configureLocation(size);

         // Call the super method.
 return super.open();
     }

     /**
      * Registers the shell as the same type as its parent with the context
      * support. This ensures that it does not modify the current state of the
      * application.
      */
     private final void registerShellType() {
         final Shell shell = getShell();
         final IContextService contextService = (IContextService) keyBindingState
                 .getAssociatedWindow().getWorkbench().getService(
                         IContextService.class);
         contextService.registerShell(shell, contextService
                 .getShellType((Shell) shell.getParent()));
     }

     /**
      * Remembers the current state of this dialog.
      *
      * @param previousWidth
      * The previous width of the dialog.
      * @param binding
      * The binding to remember, may be <code>null</code> if none.
      */
     private final void rememberState(final int previousWidth,
             final Binding binding) {
         this.previousWidth = previousWidth;
         this.binding = binding;
         hasRememberedState = true;
     }

     /**
      * Exposing this within the keys package.
      *
      * @param newParentShell
      * The new parent shell; this value may be <code>null</code> if
      * there is to be no parent.
      */
     protected final void setParentShell(final Shell newParentShell) {
         super.setParentShell(newParentShell);
     }
 }

